通过 SnapHelper 构建基于 RecyclerView 的轮播图

通过 SnapHelper 构建基于 RecyclerView 的轮播图

前言

Android 中的轮播图控件往往是基于 ViewPager 构建的,通过 setAdapter, addOnPageChangeListener,setPageTransformer 等 API 接口可以轻松实现轮播图的播放,滑动监听以及切换效果等功能,但是今天我们要另辟蹊径,通过基于 RecyclerView 这个高性能列表框架实现轮播图效果

技术背景

在 SupportLib 25.1.0 中 Android 官方为 RecyclerView 加入了一个新的类 SnapHelper,作用就是帮助 RecyclerView 滑动完成以后调整到合适的位置,并提供了两个官方子类实现 LinearSnapHelper 和 PagerSnapHelper,而其中 PagerSnapHelper 等效于 PageView 的滑动效果

SnapHelper

SnapHelper 顾名思义就是用来动态捕捉目标 view 的帮助类,比如说 PagerSnapHelper 会捕捉屏幕正中的 itemView 并通过动态计算实现 itemView 在滑动停止时居中

SnapHelper 是一个抽象类,实现了一些与 RecyclerView 相关监听方法,并且暴露了三个方法供子类实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* Override this method to snap to a particular point within the target view or the container
* view on any axis
* 覆写该方法捕捉到目标视图或者容器中的特定点或任意轴上的距离
*/
public abstract int[] calculateDistanceToFinalSnap(@NonNull LayoutManager layoutManager,
@NonNull View targetView);

/**
* Override this method to provide a particular target view for snapping
* 覆写该方法捕捉目标视图
*/
public abstract View findSnapView(LayoutManager layoutManager);

/**
* Override to provide a particular adapter target position for snapping
* 覆写该方法提供一个用于捕捉目标视图的位置,该方法用于
* fling 时
*/
public abstract int findTargetSnapPosition(LayoutManager layoutManager, int velocityX,
int velocityY);

大致的讲一下流程,SnapHelper 需要通过 attachToRecyclerView 与 RecyclerView 相关联

SnapHelper.attachToRecyclerView

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public void attachToRecyclerView(@Nullable RecyclerView recyclerView)
throws IllegalStateException {
if (mRecyclerView == recyclerView) {
return; // nothing to do
}
if (mRecyclerView != null) {
destroyCallbacks();
}
mRecyclerView = recyclerView;
if (mRecyclerView != null) {
setupCallbacks();
mGravityScroller = new Scroller(mRecyclerView.getContext(),
new DecelerateInterpolator());
snapToTargetExistingView();
}
}

主要通过 setupCallbacks 为 RecyclerView 添加 OnScrollListener 和设置 OnFlingListener,先从 OnScrollListener 开始

OnScrollListener 主要是为了监听 RecyclerView 滑动停止事件并及时调整位置效果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private final RecyclerView.OnScrollListener mScrollListener =
new RecyclerView.OnScrollListener() {
boolean mScrolled = false;

@Override
public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
super.onScrollStateChanged(recyclerView, newState);
if (newState == RecyclerView.SCROLL_STATE_IDLE && mScrolled) {
mScrolled = false;
snapToTargetExistingView();
}
}

@Override
public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
if (dx != 0 || dy != 0) {
mScrolled = true;
}
}
};

滑动以后 mScrolled 会设置滑动标志位,这样当滑动停止状态 RecyclerView.SCROLL_STATE_IDLE 触发以后才会调用 snapToTargetExistingView 去调整姿态。

SnapHelper.snapToTargetExistingView

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void snapToTargetExistingView() {
if (mRecyclerView == null) {
return;
}
LayoutManager layoutManager = mRecyclerView.getLayoutManager();
if (layoutManager == null) {
return;
}
View snapView = findSnapView(layoutManager);
if (snapView == null) {
return;
}
int[] snapDistance = calculateDistanceToFinalSnap(layoutManager, snapView);
if (snapDistance[0] != 0 || snapDistance[1] != 0) {
mRecyclerView.smoothScrollBy(snapDistance[0], snapDistance[1]);
}
}

该方法其实比较简单,通过 layoutManager 找到需要捕捉的目标 snapView,获取后再通过 layoutManager 和 snapView 计算出还需要滑动的距离最后通过 RecyclerView 平缓滑动。

还有一个关于快速滑动的特殊处理,fling 操作发生时为了防止出现快速滑动停止后有突然启动调整的情形,需要对 fling 的距离做特殊调整。 SnapHelper 实现了 RecyclerView.OnFlingListener 接口

1
2
3
4
5
6
7
8
9
10
11
12
13
public boolean onFling(int velocityX, int velocityY) {
LayoutManager layoutManager = mRecyclerView.getLayoutManager();
if (layoutManager == null) {
return false;
}
RecyclerView.Adapter adapter = mRecyclerView.getAdapter();
if (adapter == null) {
return false;
}
int minFlingVelocity = mRecyclerView.getMinFlingVelocity();
return (Math.abs(velocityY) > minFlingVelocity || Math.abs(velocityX) > minFlingVelocity)
&& snapFromFling(layoutManager, velocityX, velocityY);
}

通过 snapFromFling 方法确认是否需要拦截 fling 操作已替换成自己的逻辑实现,继续追踪

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private boolean snapFromFling(@NonNull LayoutManager layoutManager, int velocityX,
int velocityY) {
if (!(layoutManager instanceof ScrollVectorProvider)) {
return false;
}

SmoothScroller smoothScroller = createScroller(layoutManager);
if (smoothScroller == null) {
return false;
}

int targetPosition = findTargetSnapPosition(layoutManager, velocityX, velocityY);
if (targetPosition == RecyclerView.NO_POSITION) {
return false;
}

smoothScroller.setTargetPosition(targetPosition);
layoutManager.startSmoothScroll(smoothScroller);
return true;
}

在这个,通过 findTargetSnapPosition 方法获取到最终滑动的目标 view,可以是 fling 滑动一步到位,避免滑动停止后的调整操作,smoothScroller 由 createScroller 方法创建的,最终交给 createSnapScroller 实现,我们可以从 LinearSmoothScroller 类具体实现看出一些滑动细节

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
protected LinearSmoothScroller createSnapScroller(LayoutManager layoutManager) {
if (!(layoutManager instanceof ScrollVectorProvider)) {
return null;
}
return new LinearSmoothScroller(mRecyclerView.getContext()) {
@Override
protected void onTargetFound(View targetView, RecyclerView.State state, Action action) {
if (mRecyclerView == null) {
return;
}
int[] snapDistances = calculateDistanceToFinalSnap(mRecyclerView.getLayoutManager(),
targetView);
final int dx = snapDistances[0];
final int dy = snapDistances[1];
final int time = calculateTimeForDeceleration(Math.max(Math.abs(dx), Math.abs(dy)));
if (time > 0) {
action.update(dx, dy, time, mDecelerateInterpolator);
}
}

@Override
protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) {
return MILLISECONDS_PER_INCH / displayMetrics.densityDpi;
}
};
}

在 smoothScroller.setTargetPosition 设置了最终目标后,我们可以看到此时是可以得到滑动的 targetView 的,在 Scroller 的 onTargetFound 方法中需要通过 calculateDistanceToFinalSnap 去更新 action,使得最终的停止位置就是 snapView。

所以自定义的 SnapHelper 只需要实现几个抽象方法中的逻辑,我们回过头看 PageSnapHelper 的具体逻辑实现

PageSnapHelper

我们通过分析覆写的三个方法来切入

findSnapView

1
2
3
4
5
6
7
8
9
@Override
public View findSnapView(RecyclerView.LayoutManager layoutManager) {
if (layoutManager.canScrollVertically()) {
return findCenterView(layoutManager, getVerticalHelper(layoutManager));
} else if (layoutManager.canScrollHorizontally()) {
return findCenterView(layoutManager, getHorizontalHelper(layoutManager));
}
return null;
}

方法里对滑动方向做了区分,通过 findCenterView 来查找到相对居中的 view

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
private View findCenterView(RecyclerView.LayoutManager layoutManager,
OrientationHelper helper) {
int childCount = layoutManager.getChildCount();
if (childCount == 0) {
return null;
}

View closestChild = null;
final int center;
// 找到 RecyclerView 的中心点,这里会根据 clipToPadding 去除 padding 的影响
if (layoutManager.getClipToPadding()){
center = helper.getStartAfterPadding() + helper.getTotalSpace() / 2;
} else {
center = helper.getEnd() / 2;
}
int absClosest = Integer.MAX_VALUE;
// 遍历所有存在的 view 找到中心点最接近中心为位置的 snapView
for (int i = 0; i < childCount; i++) {
final View child = layoutManager.getChildAt(i);
int childCenter = helper.getDecoratedStart(child)
+ (helper.getDecoratedMeasurement(child) / 2);
int absDistance = Math.abs(childCenter - center);


if (absDistance < absClosest) {
absClosest = absDistance;
closestChild = child;
}
}
return closestChild;
}

通过 helper 可以比较容易的找到 snapView 那么计算出距离也是比较容易的,只需要将刚才计算的 RecyclerView 中心点和 SnapView 的中心点相减就行

1
2
3
4
5
6
7
8
9
10
11
12
private int distanceToCenter(@NonNull RecyclerView.LayoutManager layoutManager,
@NonNull View targetView, OrientationHelper helper) {
final int childCenter = helper.getDecoratedStart(targetView)
+ (helper.getDecoratedMeasurement(targetView) / 2);
final int containerCenter;
if (layoutManager.getClipToPadding()) {
containerCenter = helper.getStartAfterPadding() + helper.getTotalSpace() / 2;
} else {
containerCenter = helper.getEnd() / 2;
}
return childCenter - containerCenter;
}

再看是如果计算出 fling 的目标位置 findTargetSnapPosition,由于 PagerSnapHelper 比较特殊,一次只能滑动一个 view

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
@Override
public int findTargetSnapPosition(RecyclerView.LayoutManager layoutManager, int velocityX,
int velocityY) {
final int itemCount = layoutManager.getItemCount();
if (itemCount == 0) {
return RecyclerView.NO_POSITION;
}

// 获取到起始位置的 view
View mStartMostChildView = null;
if (layoutManager.canScrollVertically()) {
mStartMostChildView = findStartView(layoutManager, getVerticalHelper(layoutManager));
} else if (layoutManager.canScrollHorizontally()) {
mStartMostChildView = findStartView(layoutManager, getHorizontalHelper(layoutManager));
}
// 获取到起始位置
if (mStartMostChildView == null) {
return RecyclerView.NO_POSITION;
}
final int centerPosition = layoutManager.getPosition(mStartMostChildView);
if (centerPosition == RecyclerView.NO_POSITION) {
return RecyclerView.NO_POSITION;
}
// 找到滑动方向
final boolean forwardDirection;
if (layoutManager.canScrollHorizontally()) {
forwardDirection = velocityX > 0;
} else {
forwardDirection = velocityY > 0;
}
boolean reverseLayout = false;
if ((layoutManager instanceof RecyclerView.SmoothScroller.ScrollVectorProvider)) {
RecyclerView.SmoothScroller.ScrollVectorProvider vectorProvider =
(RecyclerView.SmoothScroller.ScrollVectorProvider) layoutManager;
PointF vectorForEnd = vectorProvider.computeScrollVectorForPosition(itemCount - 1);
if (vectorForEnd != null) {
reverseLayout = vectorForEnd.x < 0 || vectorForEnd.y < 0;
}
}
return reverseLayout
? (forwardDirection ? centerPosition - 1 : centerPosition)
: (forwardDirection ? centerPosition + 1 : centerPosition);
}

如果是正向滑动,那么返回下一个位置,因为 mStartMostChildView 还是原来的 view,如果是反向滑动,那么 mStartMostChildView 已经是前一个位置了,只要返回 centerPosition 即可。

在这个基础之上实现 ViewPager 的 OnPageChangeListener 就不难了,具体实现代码也不列举了,我已经在 github 上传了,https://github.com/sanousun/recyclerViewPager